# -*- coding: UTF-8 -*-
"""
Une bibliothèque de validateurs; chaque validateur renvoie None ou déclenche
une exception ValidationError lorsque les données qui lui sont fournies sont
invalides.

Les validateurs peuvent être des classes appelables, et ils peuvent posséder
un attribut 'always_test'. Si l'attribut 'always_test' existe (la valeur n'a
pas d'importance), le validateur sera *toujours* exécuté, et ce même si le champ
de formulaire associé n'est pas requis.
"""

import re

_datere = r'\d{4}-((?:0?[1-9])|(?:1[0-2]))-((?:0?[1-9])|(?:[12][0-9])|(?:3[0-1]))'
_timere = r'(?:[01]?[0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?'
alnum_re = re.compile(r'^\w+$')
alnumurl_re = re.compile(r'^[\w/]+$')
ansi_date_re = re.compile('^%s$' % _datere)
ansi_time_re = re.compile('^%s$' % _timere)
ansi_datetime_re = re.compile('^%s %s$' % (_datere, _timere))
email_re = re.compile(r'^[-\w.+]+@\w[\w.-]+$')
integer_re = re.compile(r'^-?\d+$')
phone_re = re.compile(r'^[A-PR-Y0-9]{3}-[A-PR-Y0-9]{3}-[A-PR-Y0-9]{4}$', re.IGNORECASE)
url_re = re.compile(r'^http://\S+$')

JING = '/usr/bin/jing'

class ValidationError(Exception):
    def __init__(self, message):
        "L'exception ValidationError peut se voir passer une chaîne ou une liste."
        if isinstance(message, list):
            self.messages = message
        else:
            assert isinstance(message, basestring), ("%s doit normalement être une chaîne." % repr(message))
            self.messages = [message]
    def __str__(self):
        # La définition de cette méthode est nécessaire car sans celle-ci,
        # l'affichage d'une instance de l'exception avec print aurait pour
        # résultat l'erreur suivante :
        # AttributeError: ValidationError instance has no attribute 'args'
        # Voir http://www.python.org/doc/current/tut/node10.html#handling
        return str(self.messages)

class CriticalValidationError(Exception):
    def __init__(self, message):
        "L'exception ValidationError peut se voir passer une chaîne ou une liste."
        if isinstance(message, list):
            self.messages = message
        else:
            assert isinstance(message, basestring), ("'%s' doit normalement être une chaîne." % message)
            self.messages = [message]
    def __str__(self):
        return str(self.messages)

def isAlphaNumeric(field_data, all_data):
    if not alnum_re.search(field_data):
        raise ValidationError, "Cette valeur doit uniquement contenir des lettres, des chiffres ou des tirets bas."

def isAlphaNumericURL(field_data, all_data):
    if not alnumurl_re.search(field_data):
        raise ValidationError, "Cette valeur doit uniquement contenir des lettres, des chiffres, des tirets bas ou des slashs."

def isLowerCase(field_data, all_data):
    if field_data.lower() != field_data:
        raise ValidationError, "Les lettres majuscules ne sont pas permises ici."

def isUpperCase(field_data, all_data):
    if field_data.upper() != field_data:
        raise ValidationError, "Les lettres minuscules ne sont pas permises ici."

def isCommaSeparatedIntegerList(field_data, all_data):
    for supposed_int in field_data.split(','):
        try:
            int(supposed_int)
        except ValueError:
            raise ValidationError, "Entrez uniquement des nombres séparés par des virgules."

def isCommaSeparatedEmailList(field_data, all_data):
    """
    Vérifie si field_data est une liste d'adresses e-mail séparées par des virgules.
    Les valeurs vierges ne déclencheront pas d'exception ValidationError et les blancs
    sont autorisés autour des virgules.
    """
    for supposed_email in field_data.split(','):
        try:
            isValidEmail(supposed_email.strip(), '')
        except ValidationError:
            raise ValidationError, "Entrez des adresses e-mail valides séparées par des virgules."

def isNotEmpty(field_data, all_data):
    if field_data.strip() == '':
        raise ValidationError, "Les valeurs vides ne sont pas permises ici."

def isOnlyDigits(field_data, all_data):
    if not field_data.isdigit():
        raise ValidationError, "Les caractères non-numériques ne sont pas permis ici."

def isNotOnlyDigits(field_data, all_data):
    if field_data.isdigit():
        raise ValidationError, "Cette valeur ne peut pas être constituée uniquement de chiffres."

def isInteger(field_data, all_data):
    # Diffère de isOnlyDigits en ce que ce validateur accepte les entiers négatifs.
    if not integer_re.search(field_data):
        raise ValidationError, "Entrez un nombre entier."

def isOnlyLetters(field_data, all_data):
    if not field_data.isalpha():
        raise ValidationError, "Seuls les caractères alphabétiques sont permis ici."

def isValidANSIDate(field_data, all_data):
    if not ansi_date_re.search(field_data):
        raise ValidationError, 'Entrez une date valide au format AAAA-MM-JJ.'

def isValidANSITime(field_data, all_data):
    if not ansi_time_re.search(field_data):
        raise ValidationError, 'Entrez une heure valide au format HH:MM.'

def isValidANSIDatetime(field_data, all_data):
    if not ansi_datetime_re.search(field_data):
        raise ValidationError, 'Entrez une date-heure valide au format AAAA-MM-JJ HH:MM.'

def isValidEmail(field_data, all_data):
    if not email_re.search(field_data):
        raise ValidationError, 'Entrez une adresse e-mail valide.'

def isValidImage(field_data, all_data):
    """
    Vérifie qu'un champ permettant d'uploader des fichiers s'est vu associer
    une image valide (GIF, JPG, PNG et autres -- toutes celles prises en charge
    par la bibliothèque d'imagerie Python, alias PIL pour Python Imaging Library).
    """
    from PIL import Image
    from cStringIO import StringIO
    try:
        Image.open(StringIO(field_data['content']))
    except IOError: # La PIL ne reconnaît pas l'image uploadée.
        raise ValidationError, "Veuillez uploader une image valide. Le fichier que vous avez uploadé n'était pas une image ou était une image corrompue."

def isValidImageURL(field_data, all_data):
    uc = URLMimeTypeCheck(('image/jpeg', 'image/gif', 'image/png'))
    try:
        uc(field_data, all_data)
    except URLMimeTypeCheck.InvalidContentType:
        raise ValidationError, "L'URL %s ne désigne pas une image valide." % field_data

def isValidPhone(field_data, all_data):
    if not phone_re.search(field_data):
        raise ValidationError, 'Les numéros de téléphone doivent être écrits sous la forme XXX-XXX-XXXX. Le numéro "%s" est invalide.' % field_data

def isValidQuicktimeVideoURL(field_data, all_data):
    "Vérifie si l'URL spécifiée est une vidéo pouvant être lue par QuickTime (qt, mpeg)."
    uc = URLMimeTypeCheck(('video/quicktime', 'video/mpeg',))
    try:
        uc(field_data, all_data)
    except URLMimeTypeCheck.InvalidContentType:
        raise ValidationError, "L'URL %s ne désigne pas une vidéo QuickTime valide." % field_data

def isValidURL(field_data, all_data):
    if not url_re.search(field_data):
        raise ValidationError, "Veuillez entrer une URL valide."

def isWellFormedXml(field_data, all_data):
    from xml.dom.minidom import parseString
    try:
        parseString(field_data)
    except Exception, e: # On utilise une clause except globale car on n'est pas trop sûr de l'exception à intercepter
        raise ValidationError, "Code XML mal formé : %s" % str(e)

def isWellFormedXmlFragment(field_data, all_data):
    isWellFormedXml('<root>%s</root>' % field_data, all_data)

def isExistingURL(field_data, all_data):
    import urllib2
    try:
        u = urllib2.urlopen(field_data)
    except ValueError:
        raise ValidationError, "URL invalide : %s" % field_data
    except: # urllib2.HTTPError, urllib2.URLError, httplib.InvalidURL etc
        raise ValidationError, "L'URL %s est un lien mort." % field_data

def isValidUSState(field_data, all_data):
    "Vérifie si la chaîne spécifiée est une abréviation valide d'un état des Etats-Unis."
    states = ['AA', 'AE', 'AK', 'AL', 'AP', 'AR', 'AS', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'FM', 'GA', 'GU', 'HI', 'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MH', 'MI', 'MN', 'MO', 'MP', 'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'PR', 'PW', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VI', 'VT', 'WA', 'WI', 'WV', 'WY']
    if field_data.upper() not in states:
        raise ValidationError, "Entrez une abréviation valide d'un état des Etats-Unis."

def hasNoProfanities(field_data, all_data):
    """
    Vérifie si la chaîne spécifiée ne contient pas de gros mots. Ce validateur
    regarde simplement si chaque gros mot répertorié est présent dans la chaîne,
    'cul' intercepte donc tous les mots composés tels 'enculé' mais aussi 'culasse'
    (à régler). Déclenche une exception ValidationError telle que :
    	Surveillez votre langage! Les mots "pu--in" et "me-de" ne sont pas permis ici.
    """
    bad_words = ['asshat', 'asshead', 'asshole', 'cunt', 'fuck', 'gook', 'nigger', 'shit'] # tous les gros mots en minuscules
    field_data = field_data.lower() # on normalise la chaîne
    words_seen = [w for w in bad_words if field_data.find(w) > -1]
    if words_seen:
        from django.utils.text import get_text_list
        plural = len(words_seen) > 1
        raise ValidationError, "Surveillez votre langage! Le%s mot%s %s %s pas permis ici." % \
            (plural and 's' or '', plural and 's' or '',
            get_text_list(['"%s%s%s"' % (i[0:2], '-'*(len(i)-4), i[-2]+i[-1]) for i in words_seen], 'et'),
            plural and 'ne sont' or 'n\'est')

class AlwaysMatchesOtherField:
    def __init__(self, other_field_name, error_message=None):
        self.other = other_field_name
        self.error_message = error_message or "Ce champ doit correspondre au champ '%s'." % self.other
        self.always_test = True

    def __call__(self, field_data, all_data):
        if field_data != all_data[self.other]:
            raise ValidationError, self.error_message

class RequiredIfOtherFieldGiven:
    def __init__(self, other_field_name, error_message=None):
        self.other = other_field_name
        self.error_message = error_message or "Veuillez remplir les deux champs ou les laisser vierges."
        self.always_test = True

    def __call__(self, field_data, all_data):
        if all_data[self.other] and not field_data:
            raise ValidationError, self.error_message

class RequiredIfOtherFieldNotGiven:
    def __init__(self, other_field_name, error_message=None):
        self.other = other_field_name
        self.error_message = error_message or "Veuillez entrer quelque chose pour au moins un champ."
        self.always_test = True

    def __call__(self, field_data, all_data):
        if not all_data.get(self.other, False) and not field_data:
            raise ValidationError, self.error_message

class RequiredIfOtherFieldsGiven:
    "Comme RequiredIfOtherFieldGiven, mais accepte une liste de noms de champs en argument et pas un seul nom de champ"
    def __init__(self, other_field_names, error_message=None):
        self.other = other_field_names
        self.error_message = error_message or "Veuillez remplir tous les champs ou les laisser vierges."
        self.always_test = True

    def __call__(self, field_data, all_data):
        for field in self.other:
            if all_data.has_key(field) and all_data[field] and not field_data:
                raise ValidationError, self.error_message

class RequiredIfOtherFieldEquals:
    def __init__(self, other_field, other_value, error_message=None):
        self.other_field = other_field
        self.other_value = other_value
        self.error_message = error_message or "Ce champ doit être rempli si %s a pour valeur %s." % (other_field, other_value)
        self.always_test = True

    def __call__(self, field_data, all_data):
        if all_data.has_key(self.other_field) and all_data[self.other_field] == self.other_value and not field_data:
            raise ValidationError(self.error_message)

class RequiredIfOtherFieldDoesNotEqual:
    def __init__(self, other_field, other_value, error_message=None):
        self.other_field = other_field
        self.other_value = other_value
        self.error_message = error_message or "Ce champ doit être rempli si %s n'a pas pour valeur %s." % (other_field, other_value)
        self.always_test = True

    def __call__(self, field_data, all_data):
        if all_data.has_key(self.other_field) and all_data[self.other_field] != self.other_value and not field_data:
            raise ValidationError(self.error_message)

class IsLessThanOtherField:
    def __init__(self, other_field_name, error_message):
        self.other, self.error_message = other_field_name, error_message

    def __call__(self, field_data, all_data):
        if field_data > all_data[self.other]:
            raise ValidationError, self.error_message

class UniqueAmongstFieldsWithPrefix:
    def __init__(self, field_name, prefix, error_message):
        self.field_name, self.prefix = field_name, prefix
        self.error_message = error_message or "Les valeurs en double ne sont pas permises."

    def __call__(self, field_data, all_data):
        for field_name, value in all_data.items():
            if field_name != self.field_name and value == field_data:
                raise ValidationError, self.error_message

class IsAPowerOf:
    """
    >>> v = IsAPowerOf(2)
    >>> v(4, None)
    >>> v(8, None)
    >>> v(16, None)
    >>> v(17, None)
    django.core.validators.ValidationError: ['Cette valeur doit être une puissance de 2.']
    """
    def __init__(self, power_of):
        self.power_of = power_of

    def __call__(self, field_data, all_data):
        from math import log
        val = log(int(field_data)) / log(self.power_of)
        if val != int(val):
            raise ValidationError, "Cette valeur doit être une puissance de %s." % self.power_of

class IsValidFloat:
    def __init__(self, max_digits, decimal_places):
        self.max_digits, self.decimal_places = max_digits, decimal_places

    def __call__(self, field_data, all_data):
        data = str(field_data)
        try:
            float(data)
        except ValueError:
            raise ValidationError, "Veuillez entrer un nombre décimal valide."
        if len(data) > (self.max_digits + 1):
            raise ValidationError, "Veuillez entrer un nombre décimal valide comportant au plus %s chiffre%s." % \
                (self.max_digits, self.max_digits > 1 and 's' or '')
        if '.' in data and len(data.split('.')[1]) > self.decimal_places:
            raise ValidationError, "Veuillez entrer un nombre décimal valide comportant au plus %s chiffre%s après la virgule." % \
                (self.decimal_places, self.decimal_places > 1 and 's' or '')

class HasAllowableSize:
    """
    Vérifie la taille des données associées à un champ permettant
    d'uploader un fichier. min_size et max_size sont exprimés en octets.
    """
    def __init__(self, min_size=None, max_size=None, min_error_message=None, max_error_message=None):
        self.min_size, self.max_size = min_size, max_size
        self.min_error_message = min_error_message or "Assurez-vous que le fichier que vous avez uploadé fait au moins %s octets." % min_size
        self.max_error_message = max_error_message or "Assurez-vous que le fichier que vous avez uploadé fait au plus %s octets." % min_size

    def __call__(self, field_data, all_data):
        if self.min_size is not None and len(field_data['content']) < self.min_size:
            raise ValidationError, self.min_error_message
        if self.max_size is not None and len(field_data['content']) > self.max_size:
            raise ValidationError, self.max_error_message

class URLMimeTypeCheck:
    "Vérifie si l'URL spécifiée désigne un document ayant pour type MIME l'un de ceux spécifiés."
    class CouldNotRetrieve(ValidationError):
        pass
    class InvalidContentType(ValidationError):
        pass

    def __init__(self, mime_type_list):
        self.mime_type_list = mime_type_list

    def __call__(self, field_data, all_data):
        import urllib2
        try:
            isValidURL(field_data, all_data)
        except ValidationError:
            raise
        try:
            info = urllib2.urlopen(field_data).info()
        except (urllib2.HTTPError, urllib2.URLError):
            raise URLMimeTypeCheck.CouldNotRetrieve, "Impossible de récupérer quelque chose à partir de l'URL %s." % field_data
        content_type = info['content-type']
        if content_type not in self.mime_type_list:
            raise URLMimeTypeCheck.InvalidContentType, "Le serveur a renvoyé un en-tête Content-Type invalide (valeur '%s') en réponse à la requête sur l'URL %s." % (content_type, field_data)

class RelaxNGCompact:
    "Valide un document XML en utilisant un schéma Relax NG (syntaxe compacte)."
    def __init__(self, schema_path, additional_root_element=None):
        self.schema_path = schema_path
        self.additional_root_element = additional_root_element

    def __call__(self, field_data, all_data):
        import os, tempfile
        if self.additional_root_element:
            field_data = '<%(are)s>%(data)s\n</%(are)s>' % {
                'are': self.additional_root_element,
                'data': field_data
            }
        filename = tempfile.mktemp() # Appel dangereux, mais rien d'autre n'a fonctionné
        fp = open(filename, 'w')
        fp.write(field_data)
        fp.close()
        if not os.path.exists(JING):
            raise Exception, "Exécutable %s introuvable!" % JING
        p = os.popen('%s -c %s %s' % (JING, self.schema_path, filename))
        errors = [line.strip() for line in p.readlines()]
        p.close()
        os.unlink(filename)
        display_errors = []
        lines = field_data.split('\n')
        for error in errors:
            _, line, level, message = error.split(':', 3)
            # On intercepte les messages d'erreur générés par Jing pour les reformuler.
            m = re.search(r'Expected "(.*?)" to terminate element starting on line (\d+)', message)
            if m:
                display_errors.append('Veuillez fermer la balise %s à la ligne %s. (La ligne commence par "%s".)' % \
                    (m.group(1).replace('/', ''), m.group(2), lines[int(m.group(2)) - 1][:30]))
                continue
            if message.strip() == 'text not allowed here':
                display_errors.append('Une portion de texte à la ligne %s est interdite dans ce contexte particulier. (La ligne commence par "%s".)' % \
                    (line, lines[int(line) - 1][:30]))
                continue
            m = re.search(r'\s*attribute "(.*?)" not allowed at this point; ignored', message)
            if m:
                display_errors.append('L\'attribut "%s" à la ligne %s est invalide. (La ligne commence par "%s".)' % \
                    (m.group(1), line, lines[int(line) - 1][:30]))
                continue
            m = re.search(r'\s*unknown element "(.*?)"', message)
            if m:
                display_errors.append('La balise "<%s>" à la ligne %s est invalide. (La ligne commence par "%s".)' % \
                    (m.group(1), line, lines[int(line) - 1][:30]))
                continue
            if message.strip() == 'required attributes missing':
                display_errors.append('Il manque un ou plusieurs attributs requis à une balise de la ligne %s. (La ligne commence par "%s".)' % \
                    (line, lines[int(line) - 1][:30]))
                continue
            m = re.search(r'\s*bad value for attribute "(.*?)"', message)
            if m:
                display_errors.append('L\'attribut "%s" à la ligne %s a une valeur invalide. (La ligne commence par "%s".)' % \
                    (m.group(1), line, lines[int(line) - 1][:30]))
                continue
            # Si tous ces tests échouent, le message d'erreur par défaut est conservé.
            display_error = 'Ligne %s : %s [%s]' % (line, message, level.strip())
            display_errors.append(display_error)
        if len(display_errors) > 0:
            raise ValidationError, display_errors
